feat(x402): add the MCP transport to the buyer skill#25
Conversation
x402 v2 defines three transports (http, mcp, a2a); MetaMask#16 landed the HTTP payer. This adds the MCP transport per specs/transports-v2/mcp.md (proposed in MetaMask#23): two new subcommands in x402_pay.py, pure stdlib. - mcp-inspect parses a paid tool's PaymentRequired challenge (JSON-RPC response, tool result, or bare object; structuredContent preferred, content[0].text fallback) and prints the options read-only. - mcp-sign --confirm validates and signs one offered option with `mm wallet sign-typed-data` and prints the PaymentPayload to place, as a raw JSON object, in the retried call's _meta["x402/payment"]. Settlement returns in _meta["x402/payment-response"]. The MCP session stays the caller's: the script signs, the caller delivers. The transport-agnostic core from MetaMask#16 (select/validate/ build_typed_data/sign_typed_data/build_payment) is reused untouched; accepts[] normalization is extracted to _normalize_accepts (shared by both transports, keeping the v1 maxAmountRequired fallback in one place). v2 only: the MCP transport is not defined for x402 v1. Conformance tests (scripts/test_x402_pay.py, stdlib unittest, no network and no mm calls) use the spec's examples verbatim; the core assertion is that the built payload equals the spec's _meta["x402/payment"] object exactly. Docs: MCP sections in references/x402.md and workflows/x402-pay.md, plus SKILL.md routing, validation, and confirmation rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q6hVwjEbcFMiGCx1Ku74tY
Verified live against cloudflare/agents' official x402-mcp example (and confirmed with a full mainnet round-trip: real Base USDC, the unmodified example server, CDP facilitator, tx 0x7f7fe537...bcd6064) that Cloudflare's Agents SDK diverges from the MCP transport spec text two ways: - The PaymentRequired challenge lives in _meta["x402/error"], not structuredContent (there is no structuredContent key at all). parse_mcp_challenge now checks it explicitly instead of relying on the content[].text fallback to catch it by coincidence. - The retry's _meta["x402/payment"] is read via JSON.parse(atob(token)) — a base64-encoded string, not the inline object the spec shows. mcp-sign now also emits paymentBase64 alongside payment, with a note on which to use for which server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q6hVwjEbcFMiGCx1Ku74tY
| ## MCP variant (paid MCP tool) | ||
|
|
||
| You (the caller) own the MCP session; the script only signs. Flow: | ||
|
|
||
| 1. Call the tool unpaid over your MCP session. A paid tool returns a result with | ||
| `isError: true` carrying the `PaymentRequired` challenge. | ||
| 2. Inspect the challenge (read-only, does not spend). Pass the tool-call response, the tool | ||
| result, or the bare `PaymentRequired` object: | ||
|
|
||
| ```bash | ||
| python3 "$SKILL_DIR/scripts/x402_pay.py" mcp-inspect --challenge '<json>' | ||
| ``` | ||
|
|
There was a problem hiding this comment.
Can you separate out workflow in x402-mcp. It reduces the chances of hallucination.
| | Check swap or bridge status | `mm swap status` | [swap.md](references/swap.md) | | ||
| | Bridge tokens to another chain | `mm swap execute` | [swap.md](references/swap.md) | | ||
| | Pay an HTTP `402` / x402 paywalled request | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) | | ||
| | Pay an x402 challenge from a paid MCP tool | `python3 scripts/x402_pay.py` | [x402.md](references/x402.md) | |
There was a problem hiding this comment.
Pay an x402-gated MCP tool call
| @@ -0,0 +1 @@ | |||
| __pycache__/ | |||
There was a problem hiding this comment.
We can remove the gitignore and test case for script. As of now we are not adding the test for scripts.
| or settlement failed); surface it to the user — do not re-sign automatically, since a new | ||
| signature is a new payment authorization. | ||
|
|
||
| ### Cloudflare Agents SDK servers (`agents/x402`) |
There was a problem hiding this comment.
This should be under ## Notes
Also, use the third person voice instead of first person in the SKILLs.
| Run `inspect` (or `mcp-inspect`) and show the user the asset, amount, network, `payTo`, and | ||
| resource. Run `pay ... --confirm` (or `mcp-sign ... --confirm`) only after the user approves. A | ||
| signature authorizes a real token debit — over MCP that holds even though the script never | ||
| touches the network, because the signed payload settles when the caller replays the tool call. |
There was a problem hiding this comment.
Instead of inspect (or mcp-inspect) we can add when to use either one. Or something that is descriptive so that agent doesn't always default inspect and sues mcp-inspect as fallback.
|
|
||
| ## MCP variant (paid MCP tool) | ||
|
|
||
| You (the caller) own the MCP session; the script only signs. Flow: |
There was a problem hiding this comment.
The caller owns the MCP session; the script only signs.
Closes #23 (proposed and scoped there). Extends #16 by @basgys, which landed the x402 HTTP payer — this adds the second of x402 v2's three transports, MCP, per the spec (
specs/transports-v2/mcp.md), so an agent can pay x402-gated MCP tools with the samemm-backed signer.What's in it
Two new subcommands in
scripts/x402_pay.py— pure stdlib, zero new dependencies:mcp-inspect— parses the paid tool'sPaymentRequiredchallenge and prints the payment options, read-only. Accepts the whole JSON-RPC response, the tool result, or the barePaymentRequiredobject (via--challenge— pass-to read stdin — or--challenge-file); checksstructuredContent,content[0].text, and_meta["x402/error"](see below).mcp-sign --confirm— validates and signs one offered option withmm wallet sign-typed-dataand prints thePaymentPayloadfor the retried tool call's_meta["x402/payment"]— both as the spec's raw JSON object (payment) and as a base64 string (paymentBase64), since real servers disagree on which one they want (see below).Design notes:
select/validate/build_typed_data/sign_typed_data/build_payment) is reused untouched;--confirm,--asset/--networkdisambiguation, and all validation guards carry over. The HTTP path is behavior-unchanged (the accepts[] normalization was extracted to a shared_normalize_accepts(), keeping the v1maxAmountRequiredfallback in one place).mcp-signrefuses a challenge without a resource URL before signing (a v2 payload must forward the resource, so signing first would burn a real authorization on a payload the facilitator rejects). Malformed offers (extra: null, stringresource) produce the JSON error contract, not tracebacks. stdin is read only via an explicit--challenge -, so a forgotten flag errors immediately instead of blocking under an agent harness.Also: conformance tests (
scripts/test_x402_pay.py, stdlibunittest, no network and nommcalls) — the core assertion is that the built payload equals the spec's_meta["x402/payment"]object exactly, plus fixtures captured verbatim from a live CloudflarewithX402run. Docs: MCP sections inreferences/x402.mdandworkflows/x402-pay.md, plus SKILL.md routing/validation/confirmation rows.Where this actually gets used
The x402 MCP transport, as documented, is early — real sellers we found gate payment with a plain HTTP 402/header regardless of protocol underneath, and CDP's own public discovery index can't even describe an in-band MCP resource (its schema's
input.typeis hardcoded to"http"). The one substantial real implementation of the spec's actual in-band mechanism is Cloudflare's Agents SDK (agents/x402,withX402/paidTool) — official, documented, and already used in production-shaped builds (Crossmint'scrossmint-agentic-financereference demo wires both sides of it).Reading Cloudflare's real server code turned up two concrete divergences from the spec text, both now handled:
_meta["x402/error"], notstructuredContent(there's nostructuredContentkey at all in Cloudflare's response)._meta["x402/payment"]is read viaJSON.parse(atob(token))— a base64-encoded string, not the inline object the spec shows.Verified
python3 scripts/test_x402_pay.py→ 33/33, including fixtures captured from a live Cloudflare run.x402-mcpexample server locally, unmodified, configured for Base mainnet + the CDP facilitator.mcp-inspect/mcp-signcorrectly parsed the real challenge and produced a payload the server accepted;mm's TEE wallet signed a real EIP-3009 authorization; the retry settled for real — tx0x7f7fe537…bcd6064, $0.01 Base USDC, independently confirmed viaeth_getTransactionReceipt(status 0x1, ERC-20 Transfer + EIP-3009AuthorizationUsedlogs both present).Field notes from building x402 payers with mm (why some choices look the way they do)
maxAmountRequiredwhere v2 usesamount; miss the fallback and you sign a null value._normalize_accepts()handles both in one place for both transports.accepts[0]: real sellers offer multiple rails (e.g. Exa lists a Solana rail alongside Base USDC). The existing--asset/--networkselection carries over to MCP unchanged.A possible follow-up (kept out of scope here, as discussed in #23): the
next_action/handoff convention some sellers use (Coinbase payments-mcp style), which wraps a standard exact-EVM offer — could ship with a redacted capture as a test fixture.cc @basgys — this extends your #16; happy to adjust naming/scope.